-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add support for asynchronous retrieval from RedisCache
#2717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did a first review pass. We need to come up with a design that doesn't introduce reactive types for all of our users as that will introduce a dependency regression.
@@ -68,6 +65,10 @@ public static BatchStrategy scan(int batchSize) { | |||
return new Scan(batchSize); | |||
} | |||
|
|||
private BatchStrategies() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Our source files have the following order:
- constructors
- (private) methods called from constructors
- static factory methods
Please revert this change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood. Out of curiosity, where is this documented?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that we are not honoring several of these Code Style guidelines:
- Import order (e.g. Java/Javax/Jakarta library imports first!)
- Location of getter/setters for fields.
- No line break after closing
}
andelse
/catch
blocks. - Our Javadoc formatting is not consistent.
- Etc.
@@ -149,6 +137,103 @@ public byte[] get(String name, byte[] key, @Nullable Duration ttl) { | |||
return result; | |||
} | |||
|
|||
@Override |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Introducing Mono
introduces a hard dependency to Project Reactor even for non-reactive users that want to use the Jedis driver. We need a different design. Either delegation or a subclass could work.
Alternatively, if we stay on CompletableFuture
, then this would move the problem to a different place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I need to think on this further, but my first reaction (no pun intended) is anyone of the approaches: delegation, subclassing or simply aligning with CompletableFuture
are all good.
I like starting from the perspective of CompletableFuture
and working backwards.
I will revise this.
} | ||
|
||
@Nullable | ||
private static byte[] nullSafeGetBytes(@Nullable ByteBuffer value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: private
methods go to the end of a source file.
ByteBuffer wrappedKey = ByteBuffer.wrap(key); | ||
|
||
// Do the same lock check as the regular Cache.get(key); be careful of blocking! | ||
Mono<ByteBuffer> cacheLockCheckMono = Mono.fromFuture(CompletableFuture.supplyAsync(() -> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't ideal to allocate and block a default thread from the ForkJoin pool as we introduce a dependency to the ForkJoin thread pool relying on a different thread availability model.
While loops are tricky with Reactor. Using Flux.generate
and (take
/skip
)(Until
/While
) would allow to build a loop based on a number of retries or a total wait duration to check whether the lock is set and proceed from there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My knowledge of Reactor is still limited in regards to the proper way to do things.
Effectively, I want the Mono
to wait for the cache lock to be released (if the cache by name was in fact locked in the first place) before attempting to do the caching operation (i.e. Redis get[Ex](..)
command). So, I wouldn't really say it is looping so much as it is waiting, even though inside checkAndPotentiallyWaitUntilUnlocked(..)
the method loops waiting for the lock to be release. Still, from the caller's perspective, it is just perceived as a "blocking" method.
I wasn't sure how to add an additional operation (i.e. checking the cache lock) to the Mono
returned from Lettuce before invoking the Redis.getExcommand in a asynchronous, non-blocking way. I tried
Mono.doFirst(:Runnable)` but that blocks before subscribing.
I may need some extra guidance here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably this needs some refinement, but as a starting point I would suggest:
Mono<Boolean> isLocked = …;
isLocked.flatMap(hasLock -> {
if(!hasLock){
return Mono.empty();
}
return Flux.interval(sleepTime).takeUntilOther(isLocked).then();
});
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I figured out what I need to do with this. In a nutshell, I used Flux.interface(delay, period).takeUntil(..)
. The tests passed as I expected them to.
// Execution Strategy applied when Jedis (non-Reactive driver) is used. | ||
// Treats API consistently (that is, using Reactive types, such as Mono) at the RedisCacheWriter level | ||
// whether "technically Reactive" or not; clearly Jedis is not "Reactive". | ||
private BiFunction<byte[], Duration, Mono<byte[]>> asyncExecutionStrategy(String cacheName) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should revisit this design approach. We previously avoided simulating reactive behavior when using blocking-only drivers across the portfolio. Ideally, we simply do not support Jedis for asynchronous cache operations. Otherwise, we open up potential for discussions leading to an increased ask to provide reactive operations on top of Jedis.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2 things.
First, my initial reaction was how to support our Jedis users, too. So this was the design I came up with.
Second, Spring Framework's contract for the Cache.retrieve(..)
operation (e.g. Javadoc) is very clear... it must not block.
In fact, the approach I took (using CompletableFuture.supplyAsync(..)
for Jedis) is precisely how Spring Framework handles asynchronous, non-blocking behavior in Cache.retrieve(..)
for caching providers that do not support asynchronous, non-blocking operations, such as ConcurrentMapCache
(source). In contrast, if the underlying caching provider, such as Caffeine, supports asynchronous, non-blocking operations (in other words, return types, such as CompletableFuture
), then the implementation is trivial (source).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, in conclusion, I would say.
Either, we support Jedis users by wrapping the simple cache op in an asynchronous manner, not unlike ConcurrentMapCache
, or we do not support Jedis at all.
It would be wrong to support Jedis, but only in a blocking manner, which would violate the contract of Cache.retrieve(..)
.
I am leaning towards supporting Jedis users where possible and the implementation is rather simple.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Either, we support Jedis users by wrapping the simple cache op in an asynchronous manner, not unlike ConcurrentMapCache, or we do not support Jedis at all.
We decided against pretending our blocking infrastructure would support asynchronicity to avoid raising false expectations. RestTemplate
, JPA, JdbcTemplate
are several examples that have followed this pattern.
It is not wise to do things just because we can.
It would be wrong to support Jedis, but only in a blocking manner, which would violate the contract of Cache.retrieve(..).
retrieve
is allowed to throw UnsupportedOperationException
. The JCache Cache doesn't support async operations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is not wise to do things just because we can.
No need to state the obvious. Also, I was not part of the original/initial "decision" so I am largely unaware and unfamiliar with what the intended direction is for Spring Data Redis. I am simply acting in the best interest of our community, and I am simply stating my case in favor of this approach, not because we "can".
Also, many caching providers don't support async caching operations yet. Neither did the ConcurrentMapCache
implementation in the core Spring Framework, but support was added for async caching behavior none-the-less.
The JCache library and overall API is an SPI (a specification, hence JSR-107), not a implementation or caching provider. This is not unlike Spring Framework's Cache Abstraction itself. As such, it is necessarily the lowest common denominator across an ecosystem of caching providers. By way of example, Caffeine, which provides async caching support, is also a JCache caching provider implementation.
Anyway, I happy to go in whatever direction we decide together.
return value != null ? value : isAllowNullValues() ? NullValue.INSTANCE : null; | ||
} | ||
|
||
@Override | ||
public CompletableFuture<?> retrieve(Object key) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should check whether the underlying RedisCacheWriter
is even capable of asynchronous cache operations. If not, then we can fall back to super.retrieve
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, currently, true async is only supported if Lettuce is on the classpath and used as the Redis driver. If Jedis is used, then it requires help, as I designed and commented to above.
* @see java.nio.ByteBuffer | ||
* @since 3.2.0 | ||
*/ | ||
default Mono<byte[]> retrieve(String name, byte[] key) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned above, declaring Mono
introduces a hard dependency on Project Reactor for all users.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In-progress on changing this API design.
* @return the {@link Mono value} to which the {@link RedisCache} maps the given {@link byte[] key}. | ||
* @throws IllegalStateException if the Redis connection factory is not reactive. | ||
* @see reactor.core.publisher.Mono | ||
* @see java.nio.ByteBuffer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Why a reference to ByteBuffer if it isn't used? Also why @see Mono
? Mono
is already linked as return type and doesn't require redundant documentation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, leftover documentation. Initially, I was using ByteBuffers
for keys and then changed the RedisCacheWriter.retrieve(..)
method signatures to align with the other RedisCacheWriter
methods and operations (i.e. using a byte[]
for keys).
My mistake.
@@ -251,13 +252,10 @@ public static ByteBuffer getByteBuffer(String theString, Charset charset) { | |||
* @param buffer must not be {@literal null}. | |||
* @return the extracted bytes. | |||
* @since 2.1 | |||
* @deprecated Use {@link #getBytes(ByteBuffer)} instead. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: We should include the version since when this has been deprecated. @Deprecated
has also a since
attribute.
assertThat(value).isNotNull(); | ||
assertThat(new String(value)).isEqualTo("test"); | ||
|
||
verify(this.mockReactiveConnectionFactory, times(1)).getReactiveConnection(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder how maintainable this will remain when we have almost 15 verifications and what net value of the test remains.
Generally looking at the new tests, there's 90% infrastructure and only 10% of actual test code left.
@mp911de & @christophstrobl - I have completely reworked this PR and removed any strict dependency on Project Reactor. The API is simply tied to However, I left the option to support Jedis in the mix. As an alternative, I added 1 more [OPTIONAL] commit demonstrating asynchronous caching behavior WITHOUT Jedis in the mix (i.e. only available when Lettuce is used), throwing the expected I urge us to consider support for Jedis. But, if we decide not to support Jedis, then we simply need to merge the [OPTIONAL] commit and answer/remove the So, in summary, this PR is ready to merge once we decide which direction to go (with Jedis support or not). |
…ucture. * Apply Java 17 syntax try-with-resources in DefaultRedisCacheWriter execute methods. * Organize source code * Edit Javadoc.
Refactors extractBytes(:ByteBuffer) to call getBytes(:ByteBuffer), thereby avoid the NullPointerException by throwing the more appropriate IllegalArgumentException with a descriptive message instead.
RedisCache
RedisCache
Replace blocking lock wait with non-blocking flow. Add support for asynchronous storage to persist the cache value after retrieval from the value supplier. Introduce AsyncCacheWriter abstraction to improve functional guards. Reformat code. Remove redundant tests. Revisit deprecation notices with consistent mention of the version in which the deprecation was introduced. Refine exception messages when RedisCache does not support async retrieval.
We now enable tests without an instance in EnabledOnRedisDriverCondition assuming that we only use DriverQualifier on instance fields and not static ones.
@@ -59,6 +60,9 @@ | |||
*/ | |||
class DefaultRedisCacheWriter implements RedisCacheWriter { | |||
|
|||
private static final boolean REACTIVE_REDIS_CONNECTION_FACTORY_PRESENT = ClassUtils | |||
.isPresent("org.springframework.data.redis.connection.ReactiveRedisConnectionFactory", null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very unnatural and ugly line break. It read nicer if ClassUtils
was on the next line, as follows:...
ClassUtils.isPresent(..)
String message = String.format("Interrupted while waiting to unlock cache %s", name); | ||
|
||
throw new PessimisticLockingFailureException(message, cause); | ||
throw new PessimisticLockingFailureException(String.format("Interrupted while waiting to unlock cache %s", name), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now you have introduced yet another unnatural line break between the formatted exception message and cause
.
* @param cacheConfiguration {@link RedisCacheConfiguration} applied to this {@link RedisCache} on creation; | ||
* must not be {@literal null}. | ||
* @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations by executing the | ||
* necessary Redis commands; must not be {@literal null}. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These Javadoc indentations are now inconsistent with the core Spring Framework Javadoc foramatting guidelines.
@@ -418,13 +429,23 @@ protected String convertKey(Object key) { | |||
return key.toString(); | |||
} | |||
|
|||
String message = String.format("Cannot convert cache key %s to String; Please register a suitable Converter" | |||
+ " via 'RedisCacheConfiguration.configureKeyConverters(...)' or override '%s.toString()'", | |||
String message = String.format( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another unnatural line break.
@@ -34,21 +34,20 @@ | |||
/** | |||
* {@link CacheManager} implementation for Redis backed by {@link RedisCache}. | |||
* <p> | |||
* This {@link CacheManager} creates {@link Cache caches} on first write, by default. Empty {@link Cache caches} | |||
* are not visible in Redis due to how Redis represents empty data structures. | |||
* This {@link CacheManager} creates {@link Cache caches} on first write, by default. Empty {@link Cache caches} are not |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not a natural appropriate place grammatically to create lines breaks in (source) Javadoc, regardless of how it is rendered (HTML, PDF, or otherwise).
I spent quite a bit of effort to clean up these sort of Javadoc issues already.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Javadoc formatting is automated, so we should not spend too much effort in manually aligning these as the formatter is going to apply its formatting anyways.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't agree with this statement. When I am working on, and maintaining source code, I am looking at the Javadoc in the source, checking for accuracy, necessary edits, completeness, etc. I am not looking to the rendered (e.g. HTML) form.
import static org.assertj.core.api.Assertions.assertThatIllegalStateException; | ||
import static org.assertj.core.api.Assumptions.assumeThat; | ||
import static org.awaitility.Awaitility.await; | ||
import static org.assertj.core.api.Assertions.*; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No star imports.
@@ -353,8 +346,9 @@ void computePrefixCreatesCacheKeyCorrectly() { | |||
|
|||
cacheWithCustomPrefix.put("key-1", sample); | |||
|
|||
doWithConnection(connection -> assertThat(connection.stringCommands() | |||
.get("_cache_key-1".getBytes(StandardCharsets.UTF_8))).isEqualTo(binarySample)); | |||
doWithConnection( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugly line break... occurring all the way down in this source file.
verifyNoMoreInteractions(mockCacheWriter); | ||
} | ||
|
||
@Test // GH-2650 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happened to these test case methods?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are testing internals (to some extent similar to testing getters and setters) and we have already tests for storing null values and null value caching in RedisCacheTests
so it doesn't make sense to have redundancy causing additional maintenance efforts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RedisCacheTests
are "Integration" Tests whereas Unit Testing is a form of White-box testing. These tests are ensuring the correct handling of null
values regardless of infrastructure concerns and are not equivalent to getter and setter testing.
I agree with removing redundancy.
These changes need further, closer review inside my IDE as they are far too extensive to review in GitHub alone. |
RedisCache
RedisCache
Cleanup ambigious, unreadable, unstructured and overly complex code in DefaultRedisCacheWriter. Split RedisCacheTests.retrieveCacheValueUsingJedis() test logic by test case. Edit Javadoc. See spring-projects#2650 Original pull request: spring-projects#2717
Cleanup ambigious, unreadable, unstructured and overly complex code in DefaultRedisCacheWriter. Split RedisCacheTests.retrieveCacheValueUsingJedis() test logic by test case. Edit Javadoc. See spring-projects#2650 Original pull request: spring-projects#2717
Cleanup ambigious, unreadable, unstructured and complex logic in DefaultRedisCacheWriter. Split RedisCacheTests.retrieveCacheValueUsingJedis() test logic by test case. Edit Javadoc. See spring-projects#2650 Original pull request: spring-projects#2717
Replace blocking lock wait with non-blocking flow. Add support for asynchronous storage to persist the cache value after retrieval from the value supplier. Introduce AsyncCacheWriter abstraction to improve functional guards. Reformat code. Remove redundant tests. Revisit deprecation notices with consistent mention of the version in which the deprecation was introduced. Refine exception messages when RedisCache does not support async retrieval. See spring-projects#2650 Original pull request: spring-projects#2717
Cleanup ambigious, unreadable, unstructured and complex logic in DefaultRedisCacheWriter. Split RedisCacheTests.retrieveCacheValueUsingJedis() test logic by test case. Edit Javadoc. See spring-projects#2650 Original pull request: spring-projects#2717
This is now reviewed, polished and merged. |
Replace blocking lock wait with non-blocking flow. Add support for asynchronous storage to persist the cache value after retrieval from the value supplier. Introduce AsyncCacheWriter abstraction to improve functional guards. Reformat code. Remove redundant tests. Revisit deprecation notices with consistent mention of the version in which the deprecation was introduced. Refine exception messages when RedisCache does not support async retrieval. See #2650 Original pull request: #2717
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a variable with strongly typed parameters. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a (functional) variable with strongly typed parameters. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a (functional) variable with strongly typed parameters. Edits and refines Javadoc. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a (functional) variable with strongly typed parameters. Edits and refines Javadoc. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a (functional) variable with strongly typed parameters. Edits and refines Javadoc. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a (functional) variable with strongly typed parameters. Edits and refines Javadoc. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
Uses more descriptive names for operations, especially Reactive operations, by either calling a local, private method or introducing a (functional) variable with strongly typed parameters. Edits and refines Javadoc. Original Pull Request: spring-projects#2717 Closes spring-projects#2743
See #2650